查看原文
其他

进阶篇|大厂常用的启动优化有哪些?

九心 鸿洋
2024-12-13

本文作者:九心,原文发布于:九心说

前言

之前有和各位同学分享过启动的两篇文章:
第一篇《Android启动这些事儿,你都拎得清吗?》从源码的角度分析了启动流程。
第二篇《进阶应用启动分析,这一篇就够了!》讲了如何使用工具测量启动流程。

今天我将结合自己的过往工作经验,分享一下常见的启动优化和一些黑科技的实操。

1准备
在正式讲优化的方法之前,默认各位同学已经掌握了:
  1. 启动的源码分析。
  2. 启动时长的监控。
因为在实际的分析过程,一定是我们懂得了自己应用的启动阶段的各个耗时点,然后对这些流程分析,最终做出针对性的优化策略。
最简单来讲,我们自己的应用的启动时长怎么定义的,启动的开始点在哪里,结束点在哪里。举个例子,我们App之前定义的两个点:
  1. 开始点:拦截的 ActivityThread 里面的消息机制 Application 创建的点。
  2. 结束点:第一个 Activity 的 onWindowsFocusChanged 方法。
看一下 Android 官方的应用启动图:
Android启动时长
我们知道,onWindowsFocusChanged 回调发生在 Activity#onCreate 之后,又在第一帧vSync之前,也就是途中的的 Displayed Time 和 reportFullyDrawn 之间。所以对于我来说,就可以知道我们的应用的优化范围在 Application#onCreate 和闪屏页,后面就可以持续的对这一块儿做优化。
另外一个重点就是关于启动工具的选择。对于启动流程的分析,我强烈建议使用 Android Studio Profiler 工具,使用其中的 TraceSystemCall 功能,优点如下:
  1. 分析各种系统资源:CPU使用情况、显示(Vsync信号、卡顿市场)、一些核心函数的耗时。
  2. 函数插桩:对于想关注的其他方法耗时,可以通过函数插装来实现。
系统资源的分析:
系统资源分析

可以看到,系统资源的展示是比较全面的。

2常用优化
先聊聊常用的优化策略吧。

1、梳理冗余逻辑

梳理冗余逻辑这个词看着比较简单,实际做起来也是不难,主要是各种业务的权衡与取舍。
有如下两点。

1.1 去除历史包袱

做启动优化的第一步是梳理启动业务流程,如果我们开发的是一个中大型应用,那么其中的很多流程是把握不准的,因此我之前的策略是和同事在周会上review代码,将启动过程的业务一个个的过,标记下不用的业务,然后在后续开发中下线。
对于更大型的团队,如大众点评,遇到相关的不熟的业务,不仅要和团队内沟通,还需要和团队外的其他业务方进行沟通,了解相关的启动业务的使用情况。

1.2 了解业务使用时机

梳理完启动业务的流程以后,我们需要对启动的业务的使用有一定的了解。对于时间偏长的任务,我们去思考一下,这个任务真的有必要在启动中去使用吗?是否可以使用懒加载,这可能需要和相关的业务方进行Argue。

2、启动框架

我想各位同学一定知道,在启动过程中,如果遇到耗时任务,可以根据情况放到异步线程,但是异步线程也会有一些特殊情况:
  • 时机保证:有一些重要的异步线程任务,如何保证在在启动结束后,能够及及时使用到对应的功能。
  • 效率保证:如何保证异步线程的数量。
  • 任务时机:有一些任务是有依赖关系的,如何保证任务的执行顺序。
  • ...
这也是我们使用启动框架原因,利用多核的CPU + 多线程,高效率、并行、有序、按时执行启动任务。
如果要做好一个启动框架,有几个重要的点:
启动框架重点

2.1 基础框架

我之前使用过的启动框架有:android-startup。
在这个框架中,启动任务分为三种:
  1. 主线程重要的任务Application#onCreate结束前主线程执行完成
  2. 子线程重要任务:交给线程池执行,也会在Application#onCreate结束前执行完成
  3. 字线程不重要的任务:交给线程池后台执行
还有一些点处理的比较好:
  1. 任务排序:很好的处理了任务依赖关系,如果发生了循环依赖,可以在任务的拓扑排序阶段,就对外抛出异常
  2. 记录任务耗时:统计好各个任务的时长,记录下来,后期有需要,可以上传到埋点
然后,这个框架有一些点还需要改进:
  1. 主线程不重要任务:我们可能还有主线程不太重要的任务,这个时候可以交给idleHandler执行
  2. 更多的执行时机:这个启动框架主要针对的时机是Application#onCreate,我们可以有更多的时机,比如首页初始化、首页空闲时、次级页面打开时,通过划分更多的时机,可以缓解CPU、线程池和内存的压力,从而降低启动时长。

2.2 动态调整启动任务的执行顺序

通常启动框架去执行启动任务的时候,顺序都是确定的。有时,我们会对外进行广告投放,想给用户一个比较有吸引力的落地页,比如我在腾讯体育看到一个京东的目的商品的广告投放页,像这样:
广告投放
这个时候落地页可能是一个活动页,那这个活动页的技术栈和首页的技术栈大概率是不一样的,那么我们是否可以针对活动页的技术栈调整一下启动任务顺序,从而降低启动时长。
简单来说,这个步骤有如下几步:
  1. 加入标记:在对外投放的Deeplink中,加入相关的标记。
  2. 识别标记:在Android或者iOS启动过程中,可以通过Hook的方式或者其他方式,在启动的早期,拿到相关的参数。
  3. 改变任务执行优先级:可以在系统中静态注册相关标记下的另外一套任务执行级顺序,或者动态下发也可以。
核心的想法就是跟落地页相关的技术栈的任务时机往前挪,不相关的任务往后挪,从而保证启动时长的最低。

3、线程梳理

在启动过程中,如果线程资源不加以限制,线程数量可能就有几百个,这会有什么问题呢?
  1. 资源消耗过高:每个线程都需要一定的系统资源,包括内存、CPU时间等。
  2. 上下文切换开销:操作系统需要在多个线程之间进行上下文切换,以便让每个线程都有机会执行。频繁的上下文切换会带来额外的开销,影响应用程序的整体性能。
线程的数量可以在性能分析工具中查看。具体的治理策略有:
  1. 避免使用new Thread的方式创建线程。
  2. 对于一些可以替换线程池的第三方库,替换成内部使用的线程池。
  3. 将第三方SDK中开源库,在核心线程空闲的时候,也能够进行释放。
先讲一下第一点,如果项目团队不大,开发的人员都在一个项目中开发,那么我们使用全局搜索就可以定位new Thread的位置,但对于第三方库中的创建却无从定位。那如果是大的项目,每个团队都有自己的开发模块,这种怎么定位呢?
Booster有给我们具体的解决方案,在字节码Transform的时候,将线程的调用方,然后传递给线程,运行的时候给它打印出来。
再简单讲一下第三点吧,可以看Booster框架,它提供了一些思路,也是在Transform的时候,将第三方的线程池的allowCoreThreadTimeOut设置为true,让它可以在空闲的时候能够进行释放,除此以外,还可以:
  • 线程池的corePoolSize设置为0。
  • 为maxPoolSize设置上限。

4、闪屏页优化

根据我的经验,闪屏一般有两种:
  1. 有对应业务的闪屏:比如说可以自定义闪屏页、或者承接开屏广告的工作,如起点读书,B站。
  2. 纯闪屏:如大众点评,京东类。

4.1 纯闪屏页

对于纯闪屏的应用,给人的感觉就是启动速度非常快,因为启动第一个Activity可能就是我们的首页。
这里有一个优化措施就是利用StartWindow机制,简单介绍一下,在Android的启动过程中,在第一个Activity真正显示之前,系统会会提供一个页面来进行过渡,我们称之为StartWindow。StartWindow会在应用的第一个Activity绘制完成以后被移除。
默认情况下根据主题而定,白色或者黑色,我们也可以设置成自定义的颜色或者图片。
具体的优化策略:
  1. 启动的时候,为首个Activity提供一个带闪屏页的主题。
  2. 在进入Activity以后,在onCreate方法中设置透明主题。
通过在onCreate中设置透明主题,我们可以减少绘制一层背景,通常我们在首页中,也不需要带背景。

4.2 携带业务的闪屏页

对于承接业务的闪屏页,这类应用启动的第一个页面一般就不是首页,它就是一个单独的闪屏页Activity,里面会有一些广告处理的逻辑。
这里也有一些具体的优化措施,除了上述的StartWindow机制以外,还有:
  1. 将xml布局的方式改成动态的创建View。
  2. 预加载闪屏页的背景图片。
一般这类的闪屏页的元素也比较简单,将xml布局改成动态创建View,可以减少读取xml文件和反射创建View的时间,在中低端的效果还是比较明显的。

5、系统资源处理

系统资源指的是锁竞争、IO治理、CPU治理,通过监控这些数据,然后分析一下其中的不合理之处,这个其实是一个细活,需要通过Perfetto和Profiler工具查看。

6、Baseline Profile

早期的Android虚拟机采用的是Dalvik,为了提高Java执行的效率,在虚拟机中采用JIT(Just in time)技术,在运行的时候将高频的方法编译成机器码,但是JIT编译的机器码是存在内存中的,下次冷启动,这些数据会丢失,对于类似服务端长期运行的Java应用来讲,提效明显。对于Android应用来讲,应用可能需要经常重启,显然就不是那么友好了。
AOT(Ahead of time)是一种预编译机制,可以将Apk中的字节码编译成二进制的机器码,减少运行时间。Android 7.0以前,在安装的时候,会将全部的字节码编译成机器码,但这会有两个问题:
  1. 安装时间长。
  2. 安装包体积大。
Android 7.0以后支持JIT和AOT并存的编译模式,其中AOT中有两种编译策略值得关注:
  1. quicken:应用安装时的编译模式,相对编译速度较快,占用空间合理。
  2. speed-profile:系统后台触发的编译模式,按照用户的习惯进行特定的优化。
所谓的Baseline Profile,指的是提前扫描我们的热点代码,生成配置文件,然后在安装的时候,对这些热点代码做AOT,可以看一下谷歌官方给的流程图:
baselineprofile_workflow
这个是借助Google Play实现的,所以针对国内的应用,可行吗?
根据网易云音乐得出来的结果,AOT其实有两种场景:
  1. 安装时AOT:在安装或者更新过程中,提前对这些热点代码aot,从而降低我们的启动时长,国内厂商对这一块儿支持的比较少,仅少数厂商支持。
  2. 还有一种场景就是启动后对Profile文件进行aot,流程如下图:
流程

从网易云优化的结果来看,第一种方案提升明显,可以降低30%的启动时长(应该是安装或者更新后的首次启动时长),第二种仅有5%。

3黑科技

我们再来聊聊黑科技,其中的一些策略需要投入比较长的时间,一个人还是比较难搞定的。

1、Apk资源重排

1.1 背景

Android底层运行着Linux系统,当App启动时,需要通过Linux系统从磁盘中加载很多文件到内存中,比如代码、资源文件(Manifest文件、布局、图片),把这些文件加载到内存中。
Linux加载文件有两种方式:
  1. 普通文件读取。
  2. 内存映射。
两种文件加载方式都会把文件内容加载到pagecache中,如果读取文件已经在pagecache中,就不会发生真正的磁盘IO,而是直接从pagecache中读取,这就大大提升读的速度。
流程如下:
pagecache流程

1.2 内部优化策略

为了提升磁盘读取效率,Linux采取了预读机制。简单来说:
  1. 单个文件的第一次读取,系统读入所请求页面的后面几个页面作为缓存。
  2. 如果下次要读取的页面不在缓存中,则表明此次的文件访问不是顺序访问,系统会采用之前的同步预读方式。
  3. 如果读的页面命中,系统会把之前预读的页面扩大一倍,但是这个过程是异步的。
如果我们启动过程中,读取的apk文件按实际加载顺序排列,就能充分的利用Linux预读机制,减少启动过程中的磁盘IO,从而降低启动时间。

1.3 技术策略

那么我们能做的就是统计这些资源文件的命中率,代码文件其实受AOT影响,拿到启动资源文件的顺序以后,重新打包,中间涉及的流程还是挺复杂的。
可以参考:《支付宝 App 构建优化解析:通过安装包重排布优化 Android 端启动性能》

2、dex2aot触发

dex2aot指的就是我们在Baseline Profile方案中说的aot,aot的时机有很多种,常见的有:
  1. 安装或者更新的时候触发。
  2. 应用空闲的时候,处在后台触发aot。
  3. 系统空闲。
这些其实是系统帮我们触发的,并且也具有不确定性,我们是否可以让应用处在后台的时候主动触发这些流程吗?
答案是肯定的,查看源码的时候发现,aot的流程都是由PackageManagerService触发的,其中的函数pefromdexOpt可以通过一些手段被我们主动触发。
可以参考:《Android ART dex2oat 浅析》。

3、启动阶段抑制GC

在启动过程中,我们希望合理的使用CPU,避免启动过程中CPU被一些任务长时间的占用。下图是通过使用字节的Btrace结合Perfetto分析得出来启动流程中的HeapTaskDaemon执行情况:
我们可以发现了HeapTaskDaemon线程占用了比较高CPU时间片,这个线程实际上是虚拟机执行GC操作的。
简单介绍一下,HeapTaskDaemon是一个守护线程,随着Zygote线程一起启动,HeapTaskDaemon做的就是无限从执行GC的HeapTask集合里面取任务执行,对于需要延时的任务,会阻塞到目标执行。
那么我们可以通过获取系统的HeapTask,并让这个HeapTask休眠,同样能达到抑制HeapTaskDaemon线程执行的目的。这个过程比较复杂,可以参考:《速度优化:GC抑制》。
需要指出的是,在 Android 8.0 以后,在应用启动的时候,会默认执行TriggerPostForkCCGcTask,该任务可以将GC延后2秒执行,所以我们看到,上面的HeapTaskDaemon并不是一开始就执行的。所以我们需要分析一下,在启动还没完成的场景下,就GC的场景是不是很多。

4、保活

现在大部分包活策略,都不太行的通了,但是之前看过某个开源项目,安装以后,即使用户手动点击强行停止,该软件也能重新启动。
开源项目:AndroidKeepAlive

https://github.com/fgkeepalive/AndroidKeepAlive


TechMerger里面的一篇文章也有介绍,原因如下:
保活
地址:https://mp.weixin.qq.com/s/E038lXvQwMCn0Neeb4RV7Q
里面的作者反编译后得出的结论是,文中的流氓软件主要做了:
  1. 被杀后重启:通过高优先级的native进程进行监听。
  2. 通过各种手段提高进程优先级。
而其中提高进程的优先级有:
  1. UI进程与Service进程分离。
  2. 使用MediaPlayer播放无声音乐。
  3. 使用AccountManager备份数据。
  4. 注册无障碍服务。
  5. 注册设备管理器。

可以看到,流氓软件还是做了很多东西,有兴趣的读者可以看一下原文。

4总结


本文中涉及到的很多内容都没有深入讲解,只是提供了一个思路和一些策略,希望做一个抛砖引玉。如果你有更好的想法,欢迎评论区留言。


最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读

鸿蒙中是如何实现UI自动刷新的?
Android从上帝视角来看PackageManagerService
深入探索 APKTool:Android 应用的反编译与重打包工具



扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

继续滑动看下一个
鸿洋
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存